Skip to content

Add Mach-O support - #50

Merged
xoofx merged 32 commits into
xoofx:masterfrom
ProjectSynchro:macho-support
Sep 3, 2026
Merged

Add Mach-O support#50
xoofx merged 32 commits into
xoofx:masterfrom
ProjectSynchro:macho-support

Conversation

@ProjectSynchro

@ProjectSynchro ProjectSynchro commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Adds Mach-O support for i386, x86_64 and arm64: read/write with a byte-exact round-trip,
segments and sections, and every load command an image of these architectures carries.
Anything unmodelled round-trips as raw bytes, so images from a newer linker still survive
a read/write.

On top of that, the two things I actually wanted it for:

  • AddLoadDylib / AddRPath / ChangeDylibName / SetInstallName: basically
    install_name_tool as a .NET API.
  • AdHocSign(identifier): SHA-256 code directory appended to __LINKEDIT. Apple Silicon
    won't run unsigned code, so an edited arm64 binary needs re-signing to stay usable.

Since the last update the rest of the support I had planned is finished: symbol table and
relocation reading, universal binaries, verification, and an otool -l-style printer with snapshots.

The whole file is one ordered content list, but only the header, the command table
and the padding after it get placed by the layout. Everything else keeps its recorded
position, because a section's address is its segment's address plus its distance from the
segment's file offset, so moving a section in the file moves it in memory too. __LINKEDIT
is the part nothing addresses that way, and that's the part a layout can actually touch.

Untouched images still come out byte-identical, which is how I checked the layout agrees
with what the linker did.

Notes

Raw structs are hand-written rather than run through LibObjectFile.CodeGen, since the first
input that comes to mind is Apple's headers and the licence isn't one this repo can bundle
(same approach the PE backend takes).

The fixtures are built by a committed script. The two 32-bit ones are synthesized with
yaml2obj rather than linked, because LLVM and current cctools have both dropped 32-bit
Mach-O linking: neither the LC_UNIXTHREAD nor the LC_MAIN-with-dyld-info shape comes out
of a linker any more, so producing them would take an older toolchain.

LibObjectFile has no Mach-O test inputs. Adds committed fixtures covering the variants an implementation has to handle: 32- and 64-bit, LC_UNIXTHREAD and LC_MAIN entry points, dyld info opcode streams and chained fixups, a dylib, an object file with relocations, and a universal binary.

Both 32-bit fixtures are synthesized with yaml2obj because LLVM and current cctools have dropped 32-bit Mach-O linking, so neither the LC_UNIXTHREAD nor the LC_MAIN-with-dyld-info shape can come from a linker any more. The sources call into libc so the linked fixtures carry lazy-binding stubs and an indirect symbol table, and the fixtures keep the padding after the load commands that in-place injection consumes.

- src/LibObjectFile.Tests/MachO/generate_files.sh: regenerates the fixtures via OSXCross cctools and LLVM, and links to where OSXCross comes from
- src/LibObjectFile.Tests/MachO/unixthread_i386_rpath: the same input after install_name_tool adds a runpath, used as an encoding reference
- src/LibObjectFile.Tests/LibObjectFile.Tests.csproj: copies the fixtures to the test output
Nothing in the library described the Mach-O format, so there was no vocabulary to write a reader against. Adds the load command, CPU, file type, header flag, segment, section, platform and relocation type enumerations, the packed version helpers, and the blittable structures matching the on-disk layout.

The structures are hand-written rather than generated from Apple's headers, whose licence this project cannot bundle, which follows what the PE support already does. A test pins every structure size, because the fields are copied by value and a wrong size would shift every later field rather than fail outright.

- src/LibObjectFile/MachO/MachOLoadCommandType.cs: keeps the LC_REQ_DYLD high bit as part of the stored value
- src/LibObjectFile/MachO/MachORelocation.cs: the two forms of entry, told apart by the top bit of the first word
- src/LibObjectFile/MachO/Internal: the on-disk structures, including the always big-endian universal binary header
The format constants had nothing to build on, so a Mach-O image could not be turned into anything inspectable. Adds the file, segment, section and load command model, and a reader that walks the command table and decodes every command an image of these architectures carries. Anything unrecognised is kept as raw bytes, so images from a newer linker still load.

Everything in the file becomes an ordered content list: the header, the load command table, the padding after it, the bytes of each section, each table in __LINKEDIT, and the gaps between them. Every byte belongs to an element, which is what makes writing the list back reproduce the image and a layout a single walk over it.

Content carrying an address is pinned, because a section's address is its segment's address plus its distance from the segment's file offset, so moving it in the file would move it in memory. Padding is kept as the bytes that were read rather than regenerated, since a linker pads executable sections with nop and zero-filling would leave a different instruction somewhere reachable.

- src/LibObjectFile/MachO/MachOFile.Read.cs: turns the file into content, leaving nothing implicit
- src/LibObjectFile/MachO/Content: the element types, and which of them a layout may move
- src/LibObjectFile/MachO/MachOPathLoadCommand.cs: keeps the linker's own padding on the commands carrying a string
Reading an image was of no use without being able to write one back. Adds the write path, which lays the content out and then writes each element at its position.

Only the header, the load command table and the padding after it are placed by that layout; everything else keeps the position recorded for it, because moving content in a Mach-O moves the addresses that refer to it. The padding is what absorbs a command table that has grown, so writing fails when the commands no longer fit rather than moving content and invalidating the image.

- src/LibObjectFile/MachO/MachOFile.Write.cs: writes the content list
- src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs: byte-exact round-trip per fixture, and every recorded file offset being reachable
Every other backend implements Verify, twenty-four types between ELF and PE, and Mach-O implemented none of it, so nothing checked the invariant the format rests on: a section's address is its segment's address plus its distance from the segment's file offset. Break that and the loader maps a section somewhere other than where the code expects, which no round-trip test would notice because the bytes still match.

Checks that relationship, that sections stay inside their segment, that a section header agrees with the content holding its bytes, that the content list covers the file with no gap or overlap, and that load command sizes are walkable by dyld. Seventy-five real images pass it.

- src/LibObjectFile/MachO/MachOFile.Verify.cs: the checks
There was no way to add a dependency or a runpath to an existing Mach-O image, which is what makes a shipped binary load a library it was not linked against. Adds the operations install_name_tool offers, appending commands into the padding the linker left ahead of the first section so no content moves and every address in the image stays valid.

A dependency is appended rather than inserted, because dyld identifies a library by its position among the load commands and the symbol table binds against that number. When the padding runs out the edit throws and names the shortfall, since the alternative is moving content and invalidating the image. Removing a dependency is deliberately not offered for the same numbering reason, matching what install_name_tool exposes.

- src/LibObjectFile/MachO/MachOFile.Edit.cs: the add, change and remove operations and the space check
- src/LibObjectFile.Tests/MachO/MachOEditingTests.cs: compares against the install_name_tool reference fixture, and asserts no section moves
Apple Silicon refuses to execute an unsigned image, and any edit invalidates an existing signature, so an arm64 binary could be modified by this library only to become unrunnable. Adds ad-hoc signing: a code directory of SHA-256 page digests plus an empty requirement set, appended to __LINKEDIT with the load command added when the image was previously unsigned.

The signature covers exactly the bytes preceding it, so its size is derived from the identifier and the signed length before any digest is taken, and the image is laid out once and then hashed as it will finally be written.

Signing has to be the last thing done, since editing afterwards leaves the digests covering bytes that are no longer there. The editing operations record that, and writing fails while it is set, so the library cannot hand back a file that looks signed and would be refused at execution.

- src/LibObjectFile/MachO/CodeSign/MachOAdHocSignatureBuilder.cs: builds the superblob, which is big-endian throughout unlike the rest of the format
- src/LibObjectFile/MachO/MachOFile.Sign.cs: appends the signature as content and grows __LINKEDIT to cover it
- src/LibObjectFile.Tests/MachO/MachOSigningTests.cs: recomputes every page digest from the written image, which is the property the kernel checks
The symbol table, the indirect symbol table and the relocations of a section were bytes that nothing decoded, so there was no way to see what an image defines, imports or fixes up. Adds reading for all three, resolving symbol names against the string table and separating the four things a symbol's type byte packs together.

An object file keeps its tables past the end of its only segment, so the lookup covers that as well as a linked image's __LINKEDIT. The result is a decoded snapshot rather than a live view, and the documentation says so, because resizing a table would move everything after it.

Relocations come in two forms sharing eight bytes, told apart by the top bit of the first word. Encoding is implemented alongside decoding and tested to round-trip, because a field read from the wrong bit still reads back consistently on its own and would otherwise look correct.

- src/LibObjectFile/MachO/MachOSymbol.cs: separates the debug, external and kind bits of the type byte
- src/LibObjectFile/MachO/MachOFile.Symbols.cs: reads both symbol tables, from a segment or from the bytes past one
- src/LibObjectFile/MachO/MachOFile.Relocations.cs: reads the entries a section points at
A universal binary could not be read at all, so an image shipping for both Intel and Apple Silicon was out of reach even though each slice inside it was already readable. Adds reading and writing of the container, exposing one image per architecture.

The header and its slice table are big-endian whatever the architectures inside are, which is the one place the format departs from the image's own byte order, so they are read through explicit big-endian primitives rather than by copying a structure. Each slice is read through a view bounded to it, so a malformed slice cannot reach into its neighbours.

The slices are laid out before writing, so one that changed size since it was read is placed and recorded correctly rather than overrunning the one after it. The space between them is left zeroed: every universal binary examined pads with zeros, and slices are page aligned rather than packed.

- src/LibObjectFile/MachO/MachOFatFile.cs: the container, its slice table and the bounded reads
- src/LibObjectFile/MachO/MachOFatSlice.cs: one architecture's placement and image
A decoded image could only be inspected through a debugger. Adds printing of the header and every load command, and snapshots the result for each fixture, so decoding a new command extends the snapshot rather than needing another test and a change to how anything is read shows up as a diff.

Commands are named by their LC_ spelling rather than the enumeration's, so output can be read next to otool's. That mapping is written out rather than derived from the enumeration names, because several do not follow from them: LC_UNIXTHREAD is one word and LC_VERSION_MIN_MACOSX breaks in places the casing does not. The command sequence each fixture prints was checked against otool before the snapshots were taken.

- src/LibObjectFile/MachO/MachOPrinter.cs: the printer
- src/LibObjectFile.Tests/Verified: one snapshot per fixture
The readme listed Mach-O among the formats left for contributors, and the manual did not mention it. Adds it to both, following how the other formats are covered: a feature list in the readme and a section in the manual with an overview, reading, writing, editing and signing.

The overview states the relationship a caller has to know about, that a section's address is its segment's address plus the section's distance from the segment's file offset, since that is what decides which parts of an image a layout may move and why adding a load command is bounded by the padding the linker left.

- readme.md: Mach-O added to the supported formats and dropped from the longer term plan
- doc/readme.md: the manual section
The signing section said an ad-hoc signature is what lets an edited arm64 image run, which is true of the kernel and not of Gatekeeper. A reader could reasonably take it to mean a signed bundle will launch, and then find that a downloaded one is refused whatever this library did to it.

Says what an ad-hoc signature does not buy: no Developer ID, no notarization, and no way back to notarized once it is replaced. Also that signing one image is not sealing a bundle, and which of the two is at stake depends on what was edited. A bundle's main executable is not listed in the seal, so re-signing it leaves the seal correct, while nested code is listed by hash and editing it does not.

- doc/readme.md: Gatekeeper, and sealing a bundle, under code signing
@ProjectSynchro

Copy link
Copy Markdown
Contributor Author

This is ready for review, I tried my best to keep tests and the API shape consistent with the ELF support that exists.

I also re-wrote the history to hopefully make this easier to review commit by commit.

This code should make it's debut in https://github.com/LaneDibello/Kotor-Patch-Manager when I have the chance to start working on integrating everything there 😄

Let me know if anything looks off to you.

@ProjectSynchro
ProjectSynchro marked this pull request as ready for review September 2, 2026 17:52
@xoofx xoofx added the enhancement New feature or request label Sep 2, 2026
@xoofx

xoofx commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Thanks a lot for this, that's very cool! I'm going to push some comments made by my AI coding agent.

@xoofx xoofx left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Focused architecture and correctness review; inline comments below.

Reviewed by GPT 5.6 Sol/High with the CodeAlta harness.

Comment thread src/LibObjectFile/MachO/MachOFile.Read.cs Outdated
Comment thread src/LibObjectFile/MachO/CodeSign/MachOAdHocSignatureBuilder.cs
Comment thread src/LibObjectFile/MachO/MachOFile.Write.cs
Comment thread src/LibObjectFile/MachO/MachOFile.Edit.cs
Comment thread src/LibObjectFile/MachO/MachOFile.cs
@ProjectSynchro

Copy link
Copy Markdown
Contributor Author

Thanks! Will take a look at patching up these holes.

The header's sizeofcmds was ignored and each command was bounded only by the whole stream, so a command declaring a cmdsize too small for its own fields still read them, taking bytes that belong to whatever follows. A count-bearing command could do the same: a segment claiming more sections than fit, or a build version claiming more tools, read section headers and tool entries out of the commands after it.

The table is now bounded by sizeofcmds and has to end exactly there, each command is checked against the size its fixed part needs before it is read, and a command that reads past its own cmdsize is reported rather than trusted. The counts inside segments and build versions are checked against the space the command declares.

Also states what file offset remapping covers, since the contract claimed every recorded offset and did not include a section's relocations. Those are now mapped. Placement is deliberately excluded and says why: a segment's or a section's file offset is where something is mapped, not merely stored, so moving it moves the thing in memory.

The remapping test read offsets back through the same walk it was testing, which made an omitted field invisible to it. It now reads them off the commands directly, and fails if the walk misses one.

- src/LibObjectFile/MachO/MachOFile.Read.cs: the bounded walk
- src/LibObjectFile/MachO/MachOLoadCommand.cs: the smallest cmdsize each kind can legally have
- src/LibObjectFile/MachO/MachOSegment.cs: section counts bounded, section relocations remapped
Writing did not verify and did not flush, so a model that Verify rejects could still be emitted, and a buffered stream could be left holding the tail of the file. ELF and PE both run Verify, then the layout, then the write, then flush.

TryWrite now does the same, and the universal binary container flushes once its slices are written.

Wiring Verify in exposed a hole signing was leaving. Aligning the signature to sixteen bytes can leave a gap before it, and nothing occupied that gap, so the content list no longer covered every byte of the file. The gap is now content like any other rather than a hole the writer happened to skip over.

- src/LibObjectFile/MachO/MachOFile.Write.cs: verify, lay out, write, flush
- src/LibObjectFile/MachO/MachOFile.Sign.cs: the alignment gap before a signature is content
- src/LibObjectFile/MachO/MachOFatFile.cs: flush after the slices
The edits assigned before they checked. ChangeDylibName and SetInstallName wrote the new name and only then asked whether it fits, so an edit that did not fit left the command renamed but not resized, describing a string longer than it has room for. AppendCommand marked the signature stale before finding out the command would not fit, so a failed edit left the image unable to be written until it was signed again.

Each now works out what the change costs, checks there is room, and only then makes it, so a failure leaves the image exactly as it was.

- src/LibObjectFile/MachO/MachOFile.Edit.cs: validate, then mutate
- src/LibObjectFile.Tests/MachO/MachOEditingTests.cs: a failed rename leaves names, sizes and the written bytes untouched
The empty CMS wrapper looked like it might not belong, since the linker-produced fixture here carries only a code directory. A linker writes a lone code directory flagged LINKER_SIGNED, which is a different thing from what signing a finished image produces: codesign writes a code directory, a twelve byte empty requirement set and an eight byte empty CMS wrapper, which is what a shipping ad-hoc signed dylib contains and what this writes.

Says so where the wrapper is written, and checks the sizes of all three blobs rather than only their count.

- src/LibObjectFile/MachO/CodeSign/MachOAdHocSignatureBuilder.cs: why the slot is present and empty
@ProjectSynchro

Copy link
Copy Markdown
Contributor Author

Should be everything the agent brought up 😄

Reviewing the load command bounds turned up three more of the same kind elsewhere.

A symbol, indirect symbol or relocation count was multiplied by an entry size in 32-bit arithmetic. A count large enough to wrap that product gave a small, plausible length that passed the coverage check, and the loop then read past what had been read. Those products are now computed in 64 bits, so the wrap cannot happen and an impossible length is reported instead. The symbol table count reached the caller as an ArgumentOutOfRangeException rather than a diagnostic.

A universal binary header's slice count was used to size an allocation before anything checked the file was big enough to hold that many. It is now checked against the bytes actually there.

Signing changed a good deal before it could fail: it removed the previous signature's content, moved the command, resized __LINKEDIT and cleared the stale flag, then wrote the image to take the digests, and that write can fail. What it changes is now put back if it does, so a failed signing leaves the image exactly as it was rather than half signed.

- src/LibObjectFile/MachO/MachOFile.Symbols.cs: lengths computed in 64 bits
- src/LibObjectFile/MachO/MachOFatFile.cs: the slice count checked before it sizes anything
- src/LibObjectFile/MachO/MachOFile.Sign.cs: signing restores what it changed if it fails
@xoofx

xoofx commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Just want to make sure that you have seen this comment

The count checks added with the previous bounds could be walked around by the arithmetic in the checks themselves. A segment declaring 0x40000001 sections multiplies out to 124 in 32 bits, the size of the command being checked, so the check passed and the section headers were read out of the commands after it. A build version declaring 0x20000000 tools multiplies out to nothing at all.

The largest count that fits is now derived by division, so there is no product to wrap. ComputeCommandSize computes wide and rejects a count that would not fit rather than returning a wrapped size, since it is public and a caller can reach it directly.

The same widening was missing where the reader gathers the regions the commands point at: those sizes were counts times an entry size in 32-bit arithmetic, so a wrapped product would have named a small region and left the rest of the table looking like padding.

- src/LibObjectFile/MachO/MachOSegment.cs: section count derived by division, command size computed wide
- src/LibObjectFile/MachO/MachOBuildVersionCommand.cs: tool count derived by division
- src/LibObjectFile/MachO/MachOFile.Read.cs: region sizes computed wide
- src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs: the two overflow counts, which fail against the multiplying checks
@ProjectSynchro

Copy link
Copy Markdown
Contributor Author

Just want to make sure that you have seen this comment

I missed that comment, it should be resolved now 😄

Comment thread src/LibObjectFile/Diagnostics/DiagnosticId.cs Outdated
@filipnavara

Copy link
Copy Markdown
Contributor

Another agent review, distilled

Bug: TryRead throws raw EndOfStreamException on short input

MachOFile.Read reads the magic with an unguarded reader.ReadU32() before any length check (src/LibObjectFile/MachO/MachOFile.Read.cs:90), so a stream shorter than 4 bytes throws instead of returning false with diagnostics — violating the Try* contract (the XML doc declares only ArgumentNullException):

MachOFile.TryRead(new MemoryStream(new byte[] { 0xCE, 0xFA, 0xED }), out _, out _);
// → EndOfStreamException: Attempted to read past the end of the stream.

The same escape is reachable through MachOFatFile.TryRead: its slice range check (MachOFatFile.cs:177) passes for a slice with Size = 0, and reading that slice's header then throws. I confirmed both with a minimal repro, and a fuzz run (truncations + bit-flips over all 8 fixtures) hit it naturally on a mutated universal binary.

Fix is small: bounds-check the magic read (or use TryReadData), and reject slices smaller than a Mach-O header (or catch/recording diagnostics around the slice read). Everything else in the fuzz held up well — ~4,200 corrupted inputs produced either clean rejections or documented ObjectFileExceptions from the on-demand readers.

Design questions worth raising

  • Strict table-end check. The reader errors when commands don't end exactly at sizeofcmds (MachOFile.Read.cs:268). dyld tolerates slack there (it just stops walking at ncmds), so unusual-but-loadable real images would fail to open here. Consider a warning, or document the strictness.
  • MachOFatFile.Write has no TryWrite and runs no Verify — unlike every other write path in the library. It also calls stream.SetLength (MachOFatFile.cs:262) without documenting that requirement.
  • AdHocSign truncating cast. (uint)contentEnd at MachOFile.Sign.cs:89 silently wraps for a >4 GB image; since dataoff is 32-bit anyway, an explicit error would be better.
  • AddLoadDylib(string, MachOLoadCommandType = ...) uses an optional parameter; AGENTS.md prefers overloads for binary compatibility.

Minor / nits

  • MACHO_ERR_InvalidSegmentFileRange (5004) is defined but never used; MACHO_ERR_ValueTooLargeFor32Bit is reused for a bitness mismatch in Verify and MACHO_ERR_InvalidSectionFileRange fires for all content regions, not just sections — names don't quite match their uses.
  • No parameterless Verify() convenience (ELF has one); doc/readme.md omits SetInstallName and RemoveRPath from the editing section.
  • Tests: the assertion at MachOSimpleTests.cs:53 is tautological (it restates the LoadCommandsEndOffset property); the space-fill loop in MachOEditingTests.cs:97-104 can pass its final add depending on leftover padding; symbol-table tests never exercise the arm64 path.
  • generate_files.sh pins no tool versions (regeneration won't be reproducible) and hardcodes an SDK path; the csproj MachO\** glob also copies the four test .cs files to output (harmless — add MachO\*.cs to the exclude).
  • The PR description says "every load command an image of these architectures carries" is decoded — several (LC_LINKER_OPTION, LC_SUB_*, LC_ROUTINES) are enumerated but kept raw. The raw round-trip makes this safe, but LC_LINKER_OPTION is common in clang object files, so it may deserve modeling later.

MACHO_ERR_InvalidSegmentFileRange was minted with the rest of the block before
the readers existed and was never raised. Give 5004 the condition that had been
borrowing MACHO_ERR_ValueTooLargeFor32Bit: a segment whose width does not match
the image it sits in is not a value that failed to fit a field.

MACHO_ERR_InvalidSectionFileRange is raised from CollectKnownRegions for every
content region, so name it after what it checks.
MachOFatFile was the one writable type in the library with no Verify and no
TryWrite, so a malformed universal binary was written out rather than reported
and a slice failure surfaced as an exception from the middle of a write.

Verify covers what only the containing file can see: slices overlapping each
other or the table they are listed in, an offset off the slice's own alignment,
a duplicate architecture, and a slice a 32-bit table cannot record. That last
one guarded the casts in the writer, which silently truncated.

Slices are now laid out before the containing file is placed, since the size
recorded for a slice has to be the size that slice goes on to write, and they
write through a writer sharing one diagnostic bag.

The reader also rejects an alignment exponent past 63. A shift count is masked
to the width of what it shifts, so a larger one aliased to a different alignment
instead of being caught.
The magic was the one read in the walk taken without a bound, so a stream of
fewer than four bytes left TryRead as an EndOfStreamException rather than false
plus diagnostics. MachOFatFile reached the same escape through a slice sized
zero, which passed the range check and was then descended into. ELF and PE both
report on the same input, so this was Mach-O being the outlier.

The magic is now read through the bounded primitive the rest of the header
already used, a slice too short to hold a header is rejected before it is read,
and TryRead converts an EndOfStreamException into a diagnostic so that a bound
missed at any of the sites that check one by hand cannot break the contract.

Fuzzing truncations and bit flips over the fixtures escaped six times before
this and none after.
ElfFile and ArArchiveFile both offer a parameterless Verify returning the bag,
which is the shape a caller wants when it has nothing to merge into. Mach-O
only had the overload taking one.

AddLoadDylib took the command type as an optional parameter. AGENTS.md prefers
overloads for binary compatibility, so it is now two methods.
The earlier overflow work bounded the counts the reader trusts. The same class
was still live on the layout side, where the values come from the model rather
than the file.

SizeOfCommands accumulated into a uint, so a command table past 4GB wrapped to a
small number. AvailableLoadCommandSpace is derived from it and gates every edit,
so the wrap offered room that did not exist. It now totals wide and saturates,
which leaves the space negative rather than plausible, and Verify reports it.
LoadCommandsEndOffset becomes a file offset like ContentStartOffset beside it,
since adding the header size to a saturated total wrapped again.

AdHocSign cast the image size down to the 32-bit offset LC_CODE_SIGNATURE
records, which for an image past 4GB pointed the signature back into the image.
It now refuses, which is already what its documented failures do.

The header test asserted HeaderSize + SizeOfCommands == LoadCommandsEndOffset,
which restates the definition of the property. It now checks the decoded table
against the ncmds and sizeofcmds the file actually carries.
The i386 fixture declared two symbols but carried no bytes for them, so every
field decoded as zero and any bug in the 32-bit nlist path would have produced
the same result as a correct read. The fixture now carries a real text and data
symbol, which llvm-nm agrees with, and the symbol test asserts against them.
yaml2obj emits a two byte export trie ahead of the tables it generates, so
LC_DYLD_INFO_ONLY now records that trie rather than leaving the bytes
unexplained, and LC_SYMTAB points past it.

The space exhaustion test filled until fewer than 120 bytes were left, then
expected a command costing about 116 to fail. Whether it did depended on the
padding the fill happened to stop on. It now asks for a path longer than the
space that is actually left.

arm64 reads symbols through the same 64-bit path as x86_64, so it is covered
against the fixture rather than as a separate decode.
SetInstallName and RemoveRPath were implemented but absent from the editing
section, and the feature list claimed all load commands are decoded in the same
breath as saying the unmodelled ones round-trip verbatim.

The write section now covers TryWrite and the universal binary, and records why
the reader insists the commands end exactly at sizeofcmds where dyld tolerates
slack after them.

generate_files.sh discovers the SDK instead of naming a version, and records the
toolchain the committed fixtures came out of, since a different one will move
offsets and show up as a diff worth reading rather than accepting.
The glob excluded MachO\*.c, which does not match MachO\*.cs, so the four test
sources were copied to the output directory alongside the fixtures. Listing the
fixtures the way the ELF and PE ones are listed states which files are inputs
instead of inferring it from an exclusion that has to stay in step.
The signature is rounded up to a 16 byte boundary, and the SuperBlob header was
given that rounded size as its own length. The length field describes the blob,
not the room reserved for it: Apple's codesign writes the offset one past the
last blob and leaves the slack to the load command's datasize, which is how it
reports the value back.

Verified against macOS 13.7.6. codesign -s - on the same input produces a
SuperBlob of 322 bytes over three slots, and after this change so does
AdHocSign, with matching slot offsets and lengths.
The section address invariant holds for a linked image, where a section's
address is its segment's address plus its distance from the segment's file
offset. An object file keeps its sections in one unnamed segment with addresses
the linker has still to assign, so they do not track file offsets at all.
Apple's own crt1.o and lazydylib1.o from the MacOSX14 SDK were rejected by it
despite reading and round-tripping byte for byte.

The committed object fixture satisfies the invariant by chance, its sections
being packed in the same order as their offsets, so the test makes the case
rather than relying on finding it.
A universal static library is a fat file whose slices are ar archives rather
than images, so reading one arrived at "Invalid Mach-O magic 0x72613C21". That
is the ar magic, and the library can read it through ArArchiveFile, so say so.
Found running the SDK's static libraries through the reader.
@ProjectSynchro

ProjectSynchro commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Took a wholesale pass since these turned out to be classes of problem due to oversight rather than one-offs.

The TryRead bug was real and a bit worse than expected: the fat path hits the same escape through a zero-sized slice. The underlying issue was that the walk uses throwing reads with bounds checked by hand at each site, so any site someone forgets escapes. Fixed the magic, rejected undersized slices, and added a backstop at the TryRead boundary. Fuzzing truncations and bit flips over the fixtures gave 6 escapes before and 0 after. ELF and PE already report on the same input, so Mach-O was the odd one out. 28e4c4d

On the CMS slot: I got a macOS VM to check this properly. Apple's own codesign -s - produces the same three slots we do, empty CMS wrapper included:

=== LibObjectFile AdHocSign        === Apple codesign -s -
  slot 0x0     CodeDirectory 266     slot 0x0     CodeDirectory 266
  slot 0x2     Requirements   12     slot 0x2     Requirements   12
  slot 0x10000 CMS Signature   8     slot 0x10000 CMS Signature   8

So ad-hoc output doesn't omit it. codesign --verify --strict passes and the edited binaries actually run, with otool reading back the injected LC_RPATH. That comparison did catch a bug though: our SuperBlob length wrongly included the alignment padding. Apple leaves that slack to the load command's datasize. It's now byte-identical. eed6fab

Two things I found while going through this both from chasing other issues:

SizeOfCommands accumulated into a uint and wrapped. AvailableLoadCommandSpace comes off it and gates every edit, so a wrap offered room that wasn't there. Same class as the count overflow, just on the write side. e7cc1fd

Verify was rejecting legitimate object files. The section address invariant only holds for linked images, and Apple's own crt1.o fails it while round-tripping byte for byte. 14786b7

MachOFatFile was the only writable type with no Verify and no TryWrite, which is why several comments land on it. It now checks slice overlap, alignment, duplicate architectures and 32-bit representability, that last one guarding casts that were silently truncating.

I left Slices as a List<T>: ObjectList<T> needs ObjectElement on both types and would duplicate FileOffset/Size, and ArArchiveFile doesn't use it for entries either. 73e8bf6

On the diagnostic ids, MACHO_ERR_InvalidSegmentFileRange was never used at all, they were all minted up front before the readers existed and the code drifted from them as I worked through the initial implementation. Reassigned it to the bitness check that had been borrowing the wrong id. d9d630d

I kept the strict sizeofcmds check and documented why: writing an image back means reproducing that slack, and one whose two counts disagree is one where it's unclear which the loader believes. Easy to make it a warning instead if you'd prefer.

Rest of the nits are done: docs cover SetInstallName and RemoveRPath, the csproj lists fixtures explicitly (the glob excluded *.c, which doesn't match *.cs), AdHocSign refuses a >4GB image instead of truncating, AddLoadDylib is two overloads, and there's a parameterless Verify() now. You're right about LC_LINKER_OPTION staying raw and the readme contradicting itself, both corrected.

Also found the i386 fixture declared two symbols but carried no bytes for them, so the 32-bit symbol path had no real coverage. It has proper symbols now. bea4183

@ProjectSynchro

ProjectSynchro commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

As a quick final test I went through my test files (including fat binaries with static libraries): Everything seems to have round tripped correctly.

The static libs in the fat binaries were cleanly rejected and nothing was throwing an exception.

Let me know if you're like me to fixup the history at any point, there are quite a few bug fixups now.

The bound was 63, which is where a shift count stops being well defined rather
than where a universal binary stops being valid. lipo refuses anything past
2^15, reporting that -segalign "must be equal to or less than 8000 (hex)",
which matches MAXSECTALIGN in cctools. At 63 we would have accepted and written
fat files Apple's own tools reject. Checked against lipo on macOS 13.7.6.
The exemption cited the fixture that exposed it rather than the rule. loader.h
states it directly: non-MH_OBJECT files have their sections padded to the
segment alignment, while MH_OBJECT keeps all sections in one segment for
compactness with no padding to a segment boundary.
@ProjectSynchro

ProjectSynchro commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Two Three more small sanity check fixes based on a spec re-read, we should be good to go.

Signing grew __LINKEDIT to cover the signature but only to exactly that size.
A segment is sized in whole units of the segment alignment, and codesign rounds
it up: signing the 8MB i386 KOTOR binary with Apple's tool takes __LINKEDIT
from 0x30D000 to 0x32C000, where we produced 0x31CB00.

It was not visible in the fixtures because their signature fits in the slack
the linker already left. A real image does not: that binary has 0x7CC spare and
the signature is about 65KB.

loader.h calls the value "the specified segment alignment" and leaves it to the
link editor, so it comes from what the toolchain does. Signing an arm64 image
rounds 0x4A50 to 0x8000, which only 16KB explains, while the linker's own x86
and x86_64 images carry sizes such as 0x30D000 that are not 16KB multiples.

Checked on macOS 13.7.6: both arm64 images pass codesign --verify --strict.
@filipnavara

Copy link
Copy Markdown
Contributor

I read the changes since yesterday and it looks good. Thanks for the work! I still didn't read manually every nook and cranny but the test coverage hopefully covers most of the low-hanging fruit.

@ProjectSynchro

ProjectSynchro commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

I read the changes since yesterday and it looks good. Thanks for the work! I still didn't read manually every nook and cranny but the test coverage hopefully covers most of the low-hanging fruit.

Great! Thanks for the read.

I've poured through the apple headers as well as the transcribed spec that was on Github just to make sure everything is kosher and nothing bad jumped out at me. (minus those couple alignment fixes.)

FWIW, I have tested this on a multitude of binaries, combinations of architectures and toolchains so I feel pretty confident that everything is covered. 😄

@xoofx xoofx left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review of the latest force-pushed head. The earlier nsects/ntools finding is resolved; I found three remaining edge cases inline.

Comment thread src/LibObjectFile/MachO/MachOFile.Read.cs
Comment thread src/LibObjectFile/MachO/MachOFatFile.Verify.cs
Comment thread src/LibObjectFile/MachO/MachOFile.Sign.cs
@xoofx

xoofx commented Sep 3, 2026

Copy link
Copy Markdown
Owner

I'm going to merge the PR as it has reached a good level of quality already. Thanks again for this PR, that's pretty cool to get mac support!

@xoofx
xoofx merged commit a530693 into xoofx:master Sep 3, 2026
1 check passed
@xoofx

xoofx commented Sep 3, 2026

Copy link
Copy Markdown
Owner

I will fix the remaining issues locally

@ProjectSynchro

Copy link
Copy Markdown
Contributor Author

Cool! Let me know if you'd like me to take on any additional fixes, I tried to cover as much as I could in this first version 😄

@xoofx

xoofx commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Available in LibObjectFile 2.3.0+ 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants